diff options
| author | Fuwn <[email protected]> | 2026-01-24 13:09:50 +0000 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-01-24 13:09:50 +0000 |
| commit | 396acf3bbbe00a192cb0ea0a9ccf91b1d8d2850b (patch) | |
| tree | b9df4ca6a70db45cfffbae6fdd7252e20fb8e93c /src/app/(main)/teams/[teamId] | |
| download | umami-main.tar.xz umami-main.zip | |
Created from https://vercel.com/new
Diffstat (limited to 'src/app/(main)/teams/[teamId]')
| -rw-r--r-- | src/app/(main)/teams/[teamId]/TeamDeleteForm.tsx | 40 | ||||
| -rw-r--r-- | src/app/(main)/teams/[teamId]/TeamEditForm.tsx | 89 | ||||
| -rw-r--r-- | src/app/(main)/teams/[teamId]/TeamManage.tsx | 32 | ||||
| -rw-r--r-- | src/app/(main)/teams/[teamId]/TeamMemberEditButton.tsx | 46 | ||||
| -rw-r--r-- | src/app/(main)/teams/[teamId]/TeamMemberEditForm.tsx | 62 | ||||
| -rw-r--r-- | src/app/(main)/teams/[teamId]/TeamMemberRemoveButton.tsx | 60 | ||||
| -rw-r--r-- | src/app/(main)/teams/[teamId]/TeamMembersDataTable.tsx | 19 | ||||
| -rw-r--r-- | src/app/(main)/teams/[teamId]/TeamMembersTable.tsx | 55 | ||||
| -rw-r--r-- | src/app/(main)/teams/[teamId]/TeamSettings.tsx | 49 | ||||
| -rw-r--r-- | src/app/(main)/teams/[teamId]/TeamWebsiteRemoveButton.tsx | 25 | ||||
| -rw-r--r-- | src/app/(main)/teams/[teamId]/TeamWebsitesDataTable.tsx | 19 | ||||
| -rw-r--r-- | src/app/(main)/teams/[teamId]/TeamWebsitesTable.tsx | 50 |
12 files changed, 546 insertions, 0 deletions
diff --git a/src/app/(main)/teams/[teamId]/TeamDeleteForm.tsx b/src/app/(main)/teams/[teamId]/TeamDeleteForm.tsx new file mode 100644 index 0000000..7adc9b3 --- /dev/null +++ b/src/app/(main)/teams/[teamId]/TeamDeleteForm.tsx @@ -0,0 +1,40 @@ +import { TypeConfirmationForm } from '@/components/common/TypeConfirmationForm'; +import { useDeleteQuery, useMessages } from '@/components/hooks'; + +const CONFIRM_VALUE = 'DELETE'; + +export function TeamDeleteForm({ + teamId, + onSave, + onClose, +}: { + teamId: string; + onSave?: () => void; + onClose?: () => void; +}) { + const { labels, formatMessage, getErrorMessage } = useMessages(); + const { mutateAsync, error, isPending, touch } = useDeleteQuery(`/teams/${teamId}`); + + const handleConfirm = async () => { + await mutateAsync(null, { + onSuccess: async () => { + touch('teams'); + touch(`teams:${teamId}`); + onSave?.(); + onClose?.(); + }, + }); + }; + + return ( + <TypeConfirmationForm + confirmationValue={CONFIRM_VALUE} + onConfirm={handleConfirm} + onClose={onClose} + isLoading={isPending} + error={getErrorMessage(error)} + buttonLabel={formatMessage(labels.delete)} + buttonVariant="danger" + /> + ); +} diff --git a/src/app/(main)/teams/[teamId]/TeamEditForm.tsx b/src/app/(main)/teams/[teamId]/TeamEditForm.tsx new file mode 100644 index 0000000..74e038f --- /dev/null +++ b/src/app/(main)/teams/[teamId]/TeamEditForm.tsx @@ -0,0 +1,89 @@ +import { + Button, + Form, + FormButtons, + FormField, + FormSubmitButton, + IconLabel, + Row, + TextField, +} from '@umami/react-zen'; +import { useMessages, useTeam, useUpdateQuery } from '@/components/hooks'; +import { RefreshCw } from '@/components/icons'; +import { getRandomChars } from '@/lib/generate'; + +const generateId = () => `team_${getRandomChars(16)}`; + +export function TeamEditForm({ + teamId, + allowEdit, + showAccessCode, + onSave, +}: { + teamId: string; + allowEdit?: boolean; + showAccessCode?: boolean; + onSave?: () => void; +}) { + const team = useTeam(); + const { formatMessage, labels, messages, getErrorMessage } = useMessages(); + + const { mutateAsync, error, isPending, touch, toast } = useUpdateQuery(`/teams/${teamId}`); + + const handleSubmit = async (data: any) => { + await mutateAsync(data, { + onSuccess: async () => { + toast(formatMessage(messages.saved)); + touch('teams'); + touch(`teams:${teamId}`); + onSave?.(); + }, + }); + }; + + return ( + <Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={{ ...team }}> + {({ setValue }) => { + return ( + <> + <FormField name="id" label={formatMessage(labels.teamId)}> + <TextField isReadOnly allowCopy /> + </FormField> + <FormField + name="name" + label={formatMessage(labels.name)} + rules={{ required: formatMessage(labels.required) }} + > + <TextField isReadOnly={!allowEdit} /> + </FormField> + {showAccessCode && ( + <Row alignItems="flex-end" gap> + <FormField + name="accessCode" + label={formatMessage(labels.accessCode)} + style={{ flex: 1 }} + > + <TextField isReadOnly allowCopy /> + </FormField> + {allowEdit && ( + <Button + onPress={() => setValue('accessCode', generateId(), { shouldDirty: true })} + > + <IconLabel icon={<RefreshCw />} label={formatMessage(labels.regenerate)} /> + </Button> + )} + </Row> + )} + {allowEdit && ( + <FormButtons justifyContent="flex-end"> + <FormSubmitButton variant="primary" isPending={isPending}> + {formatMessage(labels.save)} + </FormSubmitButton> + </FormButtons> + )} + </> + ); + }} + </Form> + ); +} diff --git a/src/app/(main)/teams/[teamId]/TeamManage.tsx b/src/app/(main)/teams/[teamId]/TeamManage.tsx new file mode 100644 index 0000000..88cbad9 --- /dev/null +++ b/src/app/(main)/teams/[teamId]/TeamManage.tsx @@ -0,0 +1,32 @@ +import { Button, Dialog, DialogTrigger, Modal } from '@umami/react-zen'; +import { useRouter } from 'next/navigation'; +import { ActionForm } from '@/components/common/ActionForm'; +import { useMessages, useModified } from '@/components/hooks'; +import { TeamDeleteForm } from './TeamDeleteForm'; + +export function TeamManage({ teamId }: { teamId: string }) { + const { formatMessage, labels, messages } = useMessages(); + const router = useRouter(); + const { touch } = useModified(); + + const handleLeave = async () => { + touch('teams'); + router.push('/settings/teams'); + }; + + return ( + <ActionForm + label={formatMessage(labels.deleteTeam)} + description={formatMessage(messages.deleteTeamWarning)} + > + <DialogTrigger> + <Button variant="danger">{formatMessage(labels.delete)}</Button> + <Modal> + <Dialog title={formatMessage(labels.deleteTeam)} style={{ width: 400 }}> + {({ close }) => <TeamDeleteForm teamId={teamId} onSave={handleLeave} onClose={close} />} + </Dialog> + </Modal> + </DialogTrigger> + </ActionForm> + ); +} diff --git a/src/app/(main)/teams/[teamId]/TeamMemberEditButton.tsx b/src/app/(main)/teams/[teamId]/TeamMemberEditButton.tsx new file mode 100644 index 0000000..f75b6d1 --- /dev/null +++ b/src/app/(main)/teams/[teamId]/TeamMemberEditButton.tsx @@ -0,0 +1,46 @@ +import { useToast } from '@umami/react-zen'; +import { useMessages, useModified } from '@/components/hooks'; +import { Edit } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import { TeamMemberEditForm } from './TeamMemberEditForm'; + +export function TeamMemberEditButton({ + teamId, + userId, + role, + onSave, +}: { + teamId: string; + userId: string; + role: string; + onSave?: () => void; +}) { + const { formatMessage, labels, messages } = useMessages(); + const { toast } = useToast(); + const { touch } = useModified(); + + const handleSave = () => { + touch('teams:members'); + toast(formatMessage(messages.saved)); + onSave?.(); + }; + + return ( + <DialogButton + icon={<Edit />} + title={formatMessage(labels.editMember)} + variant="quiet" + width="400px" + > + {({ close }) => ( + <TeamMemberEditForm + teamId={teamId} + userId={userId} + role={role} + onSave={handleSave} + onClose={close} + /> + )} + </DialogButton> + ); +} diff --git a/src/app/(main)/teams/[teamId]/TeamMemberEditForm.tsx b/src/app/(main)/teams/[teamId]/TeamMemberEditForm.tsx new file mode 100644 index 0000000..4826746 --- /dev/null +++ b/src/app/(main)/teams/[teamId]/TeamMemberEditForm.tsx @@ -0,0 +1,62 @@ +import { + Button, + Form, + FormButtons, + FormField, + FormSubmitButton, + ListItem, + Select, +} from '@umami/react-zen'; +import { useMessages, useUpdateQuery } from '@/components/hooks'; +import { ROLES } from '@/lib/constants'; + +export function TeamMemberEditForm({ + teamId, + userId, + role, + onSave, + onClose, +}: { + teamId: string; + userId: string; + role: string; + onSave?: () => void; + onClose?: () => void; +}) { + const { mutateAsync, error, isPending } = useUpdateQuery(`/teams/${teamId}/users/${userId}`); + const { formatMessage, labels, getErrorMessage } = useMessages(); + + const handleSubmit = async (data: any) => { + await mutateAsync(data, { + onSuccess: async () => { + onSave(); + onClose(); + }, + }); + }; + + return ( + <Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={{ role }}> + <FormField + name="role" + rules={{ required: formatMessage(labels.required) }} + label={formatMessage(labels.role)} + > + <Select> + <ListItem id={ROLES.teamManager}>{formatMessage(labels.manager)}</ListItem> + <ListItem id={ROLES.teamMember}>{formatMessage(labels.member)}</ListItem> + <ListItem id={ROLES.teamViewOnly}>{formatMessage(labels.viewOnly)}</ListItem> + </Select> + </FormField> + + <FormButtons> + <Button isDisabled={isPending} onPress={onClose}> + {formatMessage(labels.cancel)} + </Button> + <FormSubmitButton variant="primary" isDisabled={false}> + {formatMessage(labels.save)} + </FormSubmitButton> + </FormButtons> + </Form> + ); +} diff --git a/src/app/(main)/teams/[teamId]/TeamMemberRemoveButton.tsx b/src/app/(main)/teams/[teamId]/TeamMemberRemoveButton.tsx new file mode 100644 index 0000000..4d3e8e9 --- /dev/null +++ b/src/app/(main)/teams/[teamId]/TeamMemberRemoveButton.tsx @@ -0,0 +1,60 @@ +import { ConfirmationForm } from '@/components/common/ConfirmationForm'; +import { useDeleteQuery, useMessages, useModified } from '@/components/hooks'; +import { Trash } from '@/components/icons'; +import { DialogButton } from '@/components/input/DialogButton'; +import { messages } from '@/components/messages'; + +export function TeamMemberRemoveButton({ + teamId, + userId, + userName, + onSave, +}: { + teamId: string; + userId: string; + userName: string; + disabled?: boolean; + onSave?: () => void; +}) { + const { formatMessage, labels, FormattedMessage } = useMessages(); + const { mutateAsync, isPending, error } = useDeleteQuery(`/teams/${teamId}/users/${userId}`); + const { touch } = useModified(); + + const handleConfirm = async (close: () => void) => { + await mutateAsync(null, { + onSuccess: () => { + touch('teams:members'); + onSave?.(); + close(); + }, + }); + }; + + return ( + <DialogButton + icon={<Trash />} + title={formatMessage(labels.confirm)} + variant="quiet" + width="400px" + > + {({ close }) => ( + <ConfirmationForm + message={ + <FormattedMessage + {...messages.confirmRemove} + values={{ + target: <b>{userName}</b>, + }} + /> + } + isLoading={isPending} + error={error} + onConfirm={handleConfirm.bind(null, close)} + onClose={close} + buttonLabel={formatMessage(labels.remove)} + buttonVariant="danger" + /> + )} + </DialogButton> + ); +} diff --git a/src/app/(main)/teams/[teamId]/TeamMembersDataTable.tsx b/src/app/(main)/teams/[teamId]/TeamMembersDataTable.tsx new file mode 100644 index 0000000..52c0fe3 --- /dev/null +++ b/src/app/(main)/teams/[teamId]/TeamMembersDataTable.tsx @@ -0,0 +1,19 @@ +import { DataGrid } from '@/components/common/DataGrid'; +import { useTeamMembersQuery } from '@/components/hooks'; +import { TeamMembersTable } from './TeamMembersTable'; + +export function TeamMembersDataTable({ + teamId, + allowEdit = false, +}: { + teamId: string; + allowEdit?: boolean; +}) { + const queryResult = useTeamMembersQuery(teamId); + + return ( + <DataGrid query={queryResult} allowSearch> + {({ data }) => <TeamMembersTable data={data} teamId={teamId} allowEdit={allowEdit} />} + </DataGrid> + ); +} diff --git a/src/app/(main)/teams/[teamId]/TeamMembersTable.tsx b/src/app/(main)/teams/[teamId]/TeamMembersTable.tsx new file mode 100644 index 0000000..8414908 --- /dev/null +++ b/src/app/(main)/teams/[teamId]/TeamMembersTable.tsx @@ -0,0 +1,55 @@ +import { DataColumn, DataTable, Row } from '@umami/react-zen'; +import { useMessages } from '@/components/hooks'; +import { ROLES } from '@/lib/constants'; +import { TeamMemberEditButton } from './TeamMemberEditButton'; +import { TeamMemberRemoveButton } from './TeamMemberRemoveButton'; + +export function TeamMembersTable({ + data = [], + teamId, + allowEdit = false, +}: { + data: any[]; + teamId: string; + allowEdit: boolean; +}) { + const { formatMessage, labels } = useMessages(); + + const roles = { + [ROLES.teamOwner]: formatMessage(labels.teamOwner), + [ROLES.teamManager]: formatMessage(labels.teamManager), + [ROLES.teamMember]: formatMessage(labels.teamMember), + [ROLES.teamViewOnly]: formatMessage(labels.viewOnly), + }; + + return ( + <DataTable data={data}> + <DataColumn id="username" label={formatMessage(labels.username)}> + {(row: any) => row?.user?.username} + </DataColumn> + <DataColumn id="role" label={formatMessage(labels.role)}> + {(row: any) => roles[row?.role]} + </DataColumn> + {allowEdit && ( + <DataColumn id="action" align="end"> + {(row: any) => { + if (row?.role === ROLES.teamOwner) { + return null; + } + + return ( + <Row alignItems="center" maxHeight="20px"> + <TeamMemberEditButton teamId={teamId} userId={row?.user?.id} role={row?.role} /> + <TeamMemberRemoveButton + teamId={teamId} + userId={row?.user?.id} + userName={row?.user?.username} + /> + </Row> + ); + }} + </DataColumn> + )} + </DataTable> + ); +} diff --git a/src/app/(main)/teams/[teamId]/TeamSettings.tsx b/src/app/(main)/teams/[teamId]/TeamSettings.tsx new file mode 100644 index 0000000..3ddbe00 --- /dev/null +++ b/src/app/(main)/teams/[teamId]/TeamSettings.tsx @@ -0,0 +1,49 @@ +import { Column } from '@umami/react-zen'; +import { TeamLeaveButton } from '@/app/(main)/teams/TeamLeaveButton'; +import { PageHeader } from '@/components/common/PageHeader'; +import { Panel } from '@/components/common/Panel'; +import { useLoginQuery, useNavigation, useTeam } from '@/components/hooks'; +import { Users } from '@/components/icons'; +import { ROLES } from '@/lib/constants'; +import { TeamEditForm } from './TeamEditForm'; +import { TeamManage } from './TeamManage'; +import { TeamMembersDataTable } from './TeamMembersDataTable'; + +export function TeamSettings({ teamId }: { teamId: string }) { + const team: any = useTeam(); + const { user } = useLoginQuery(); + const { pathname } = useNavigation(); + + const isAdmin = pathname.includes('/admin'); + + const isTeamOwner = + !!team?.members?.find(({ userId, role }) => role === ROLES.teamOwner && userId === user.id) && + user.role !== ROLES.viewOnly; + + const canEdit = + user.isAdmin || + (!!team?.members?.find( + ({ userId, role }) => + (role === ROLES.teamOwner || role === ROLES.teamManager) && userId === user.id, + ) && + user.role !== ROLES.viewOnly); + + return ( + <Column gap="6"> + <PageHeader title={team?.name} icon={<Users />}> + {!isTeamOwner && !isAdmin && <TeamLeaveButton teamId={team.id} teamName={team.name} />} + </PageHeader> + <Panel> + <TeamEditForm teamId={teamId} allowEdit={canEdit} showAccessCode={canEdit} /> + </Panel> + <Panel> + <TeamMembersDataTable teamId={teamId} allowEdit={canEdit} /> + </Panel> + {isTeamOwner && ( + <Panel> + <TeamManage teamId={teamId} /> + </Panel> + )} + </Column> + ); +} diff --git a/src/app/(main)/teams/[teamId]/TeamWebsiteRemoveButton.tsx b/src/app/(main)/teams/[teamId]/TeamWebsiteRemoveButton.tsx new file mode 100644 index 0000000..f2b4ece --- /dev/null +++ b/src/app/(main)/teams/[teamId]/TeamWebsiteRemoveButton.tsx @@ -0,0 +1,25 @@ +import { Icon, LoadingButton, Text } from '@umami/react-zen'; +import { useDeleteQuery, useMessages } from '@/components/hooks'; +import { X } from '@/components/icons'; + +export function TeamWebsiteRemoveButton({ teamId, websiteId, onSave }) { + const { formatMessage, labels } = useMessages(); + const { mutateAsync } = useDeleteQuery(`/teams/${teamId}/websites/${websiteId}`); + + const handleRemoveTeamMember = async () => { + await mutateAsync(null, { + onSuccess: () => { + onSave(); + }, + }); + }; + + return ( + <LoadingButton variant="quiet" onClick={() => handleRemoveTeamMember()}> + <Icon> + <X /> + </Icon> + <Text>{formatMessage(labels.remove)}</Text> + </LoadingButton> + ); +} diff --git a/src/app/(main)/teams/[teamId]/TeamWebsitesDataTable.tsx b/src/app/(main)/teams/[teamId]/TeamWebsitesDataTable.tsx new file mode 100644 index 0000000..6a2e4f4 --- /dev/null +++ b/src/app/(main)/teams/[teamId]/TeamWebsitesDataTable.tsx @@ -0,0 +1,19 @@ +import { DataGrid } from '@/components/common/DataGrid'; +import { useTeamWebsitesQuery } from '@/components/hooks'; +import { TeamWebsitesTable } from './TeamWebsitesTable'; + +export function TeamWebsitesDataTable({ + teamId, + allowEdit = false, +}: { + teamId: string; + allowEdit?: boolean; +}) { + const queryResult = useTeamWebsitesQuery(teamId); + + return ( + <DataGrid query={queryResult} allowSearch> + {({ data }) => <TeamWebsitesTable data={data} teamId={teamId} allowEdit={allowEdit} />} + </DataGrid> + ); +} diff --git a/src/app/(main)/teams/[teamId]/TeamWebsitesTable.tsx b/src/app/(main)/teams/[teamId]/TeamWebsitesTable.tsx new file mode 100644 index 0000000..10f5654 --- /dev/null +++ b/src/app/(main)/teams/[teamId]/TeamWebsitesTable.tsx @@ -0,0 +1,50 @@ +import { DataColumn, DataTable, Row } from '@umami/react-zen'; +import Link from 'next/link'; +import { TeamMemberEditButton } from '@/app/(main)/teams/[teamId]/TeamMemberEditButton'; +import { TeamMemberRemoveButton } from '@/app/(main)/teams/[teamId]/TeamMemberRemoveButton'; +import { useMessages } from '@/components/hooks'; +import { ROLES } from '@/lib/constants'; + +export function TeamWebsitesTable({ + teamId, + data = [], + allowEdit, +}: { + teamId: string; + data: any[]; + allowEdit: boolean; +}) { + const { formatMessage, labels } = useMessages(); + + return ( + <DataTable data={data}> + <DataColumn id="name" label={formatMessage(labels.name)}> + {(row: any) => <Link href={`/teams/${teamId}/websites/${row.id}`}>{row.name}</Link>} + </DataColumn> + <DataColumn id="domain" label={formatMessage(labels.domain)} /> + <DataColumn id="createdBy" label={formatMessage(labels.createdBy)}> + {(row: any) => row?.createUser?.username} + </DataColumn> + {allowEdit && ( + <DataColumn id="action" align="end"> + {(row: any) => { + if (row?.role === ROLES.teamOwner) { + return null; + } + + return ( + <Row alignItems="center"> + <TeamMemberEditButton teamId={teamId} userId={row?.user?.id} role={row?.role} /> + <TeamMemberRemoveButton + teamId={teamId} + userId={row?.user?.id} + userName={row?.user?.username} + /> + </Row> + ); + }} + </DataColumn> + )} + </DataTable> + ); +} |